多數系統都有日誌。但當事故發生、稽核來問「上個月 3 月 15 日下午,誰還原了張三的資料」,多數系統答不出來。
原因通常是:
最後一項很諷刺但很常見。
一、完整性:所有敏感操作都要記錄。
哪些算敏感操作?
文件上傳
偵測執行(含 findings 統計,不含原文)
去識別化執行
送出至外部模型
還原請求(成功與失敗都要)
政策變更
權限變更
Vault 清除
金鑰輪替
失敗的還原請求特別重要——連續多次失敗的還原嘗試是攻擊的訊號。
二、不可否認性:能歸屬到具體的人。
服務帳號不算。如果日誌寫的是 service-account-app@project.iam,那你只知道「應用程式做的」。必須把終端使用者身分帶進來:
audit_entry = {
"actor": {
"principal": ctx.user_email, # 終端使用者
"service_account": ctx.sa_email, # 執行身分
"auth_method": ctx.auth_method, # SSO / MFA
"source_ip": ctx.source_ip,
},
...
}
三、不可竄改性:寫入後不能改。
用 append-only 的儲存,或者用 Cloud Logging 的 _Required bucket(不可刪除、不可修改、固定保留 400 天)。
更強的做法是把稽核日誌寫到獨立專案,該專案的權限與業務專案完全分離——業務專案的最高權限也不能碰稽核專案的日誌。
四、可串接:一個請求的全流程能被還原。
這需要 trace ID 貫穿全鏈:
def handle_request(req):
trace_id = req.headers.get("X-Trace-Id") or str(uuid4())
ctx = Context(trace_id=trace_id, user=req.user)
audit("document_upload", ctx, {"doc_hash": sha256(req.file)})
text = extract(req.file, ctx)
findings = detect(text, ctx)
audit("detection", ctx, {
"finding_count": len(findings),
"types": Counter(f["type"] for f in findings),
})
deid = deidentify(text, findings, ctx)
audit("deidentification", ctx, {"transform_count": len(findings)})
result = call_llm(deid, ctx)
audit("external_call", ctx, {
"model": MODEL_NAME,
"input_hash": sha256(deid),
})
return result
有了 trace_id,稽核可以問「這份摘要是從哪份文件來的、中間經過什麼處理」,然後把整條鏈拉出來。
原文。 不用說。
findings 的 quote。 這是 D7 提過的 include_quote 陷阱。偵測結果如果包含被偵測到的文字,日誌就變成個資清單。
Token 的原值。 可以記 Token 的雜湊,不要記 Token 本身——因為 Token 加上 Vault 存取權就等於明文。
錯誤堆疊裡的變數內容。 這是最陰險的一個。異常處理如果把整個 request body dump 進日誌,前面所有努力都白費。
class SafeFormatter(logging.Formatter):
SENSITIVE_KEYS = {"text", "content", "value", "quote",
"document", "body", "raw"}
def format(self, record):
if hasattr(record, "extra_data"):
record.extra_data = {
k: ("[REDACTED]" if k in self.SENSITIVE_KEYS else v)
for k, v in record.extra_data.items()
}
return super().format(record)
而且要在例外處理器層級也做一次,因為第三方套件拋出的例外可能夾帶內容:
def safe_exception_handler(exc):
log.error(
"處理失敗:%s",
type(exc).__name__, # 只記類型
extra={"trace_id": ctx.trace_id},
)
# 不要 log.exception(),它會帶出完整堆疊與區域變數
{
"timestamp": "2026-03-15T14:23:07.123+08:00",
"trace_id": "7f3a9c2e-...",
"action": "reidentify",
"outcome": "success",
"actor": {
"principal": "lawyer.a@example.com",
"service_account": "reidentify-svc@project.iam",
"auth_method": "SSO+MFA",
"source_ip": "10.20.30.40"
},
"target": {
"scope_id": "case:2026-CV-00123",
"token_count": 3,
"token_hashes": ["a3f9...", "b21c...", "c88d..."],
"entity_types": ["PERSON", "PHONE_NUMBER"]
},
"justification": {
"reason_code": "COURT_FILING",
"reason_text": "準備民事起訴狀正本",
"approval_ref": null
},
"policy_version": "v2.3.1",
"key_version": "3"
}
policy_version 和 key_version 這兩個欄位常被漏掉,但它們讓你能回答:「當時用的是哪個版本的偵測規則?」——這在事後檢討「為什麼那筆沒被抓到」時是必要的。
有了結構化日誌,可以做異常偵測。幾個值得設的告警:
| 訊號 | 可能意義 |
|---|---|
| 單一使用者單日還原量 > 平時 5 倍 | 批次竊取 |
| 非上班時間的還原 | 憑證盜用 |
| 連續多次還原失敗 | 探測攻擊 |
| 某個 scope 被大量不同使用者存取 | 權限設定錯誤 |
| 偵測 findings 數突然歸零 | 偵測引擎失效(靜默失效!) |
| 同一 Token 短時間被多人還原 | 資訊擴散 |
倒數第二列最重要。 如果偵測引擎壞了、規則被誤刪、模型載入失敗,findings 數會變成 0,而系統看起來一切正常——文件照樣處理、照樣送出,只是沒有任何東西被保護。
這是 D3 威脅模型裡沒有列到但同樣致命的情境:不是被攻擊,是自己壞掉。
def check_detection_health(window_hours=1):
stats = query_audit_logs(action="detection", hours=window_hours)
if not stats:
return
zero_ratio = sum(1 for s in stats if s["finding_count"] == 0) / len(stats)
baseline = get_baseline_zero_ratio()
if zero_ratio > baseline * 3:
alert("偵測引擎可能失效:零命中率異常升高")
金融業的稽核紀錄保存要求依業務類型而異,個資相關的處理紀錄通常要留數年。實際年限要跟法遵確認,但有一個原則:
稽核日誌的保存期,應該長於它所記錄的資料的保存期。
因為 Vault 裡的 Token 對應清掉之後,你仍然需要能回答「這筆資料曾經存在、曾經被誰處理過」。日誌比資料活得久,才有意義。
明天談 fail-closed:偵測引擎掛掉的時候該怎麼辦。
我是 Fngi,專注在 AI 資安、LLM 紅隊與 AI 治理框架落地。
IG:@aid3fend
有想討論的架構細節或不同意見,留言或私訊都歡迎。